EC2 Instance Connect Endpoint で PrivateWithEgress に配置された EC2 Instance に接続する構成を AWS CDK で作成してみた

EC2 Instance Connect Endpoint で PrivateWithEgress に配置された EC2 Instance に接続する構成を AWS CDK で作成してみた

Clock Icon2024.09.29

こんにちは、製造ビジネステクノロジー部の若槻です。

EC2 Instance Connect Endpoint を使用すると、踏み台ホストなどを使用せずにプライベートサブネット内の EC2 インスタンスに SSH や RDP 接続が可能となります。この機能は昨年 6 月に提供開始されました。

https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/connect-with-ec2-instance-connect-endpoint.html
https://dev.classmethod.jp/articles/update-ec2-instance-connect-endpoint/

今回は、この EC2 Instance Connect Endpoint で PrivateWithEgress のサブネットに配置された EC2 Instance に SSH 接続可能とする構成のリソース一式を AWS CDK で作成してみました。

試してみた

CDK コード

リソース一式を作成する CDK コードは次のようになります。(2025/01/03 更新。セキュリティグループを Construct 分割して可読性を確保する改善を行った。)

lib/cdk-sample-stack.ts
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

/**
 * セキュリティグループ用 Construct
 */
class SecurityGroupConstruct extends Construct {
  public readonly forInstanceConnectEndpoint: ec2.SecurityGroup;
  public readonly forEc2Instance: ec2.SecurityGroup;

  constructor(scope: Construct, id: string, vpc: ec2.IVpc) {
    super(scope, id);

    // EC2 Instance Connect Endpoint 用セキュリティグループの作成
    const forInstanceConnectEndpoint = new ec2.SecurityGroup(
      this,
      'ForInstanceConnectEndpoint',
      {
        vpc,
        allowAllOutbound: false,
      }
    );

    // EC2 Instance 用セキュリティグループの作成
    const forEc2Instance = new ec2.SecurityGroup(this, 'ForEc2Instance', {
      vpc,
      allowAllOutbound: false,
    });

    // EC2 Instance Connect Endpoint から EC2 Instance への 22 ポートの通信を許可
    forInstanceConnectEndpoint.connections.allowTo(
      forEc2Instance,
      ec2.Port.tcp(22)
    );

    // EC2 Instance から外部への通信は 443 ポートのみ許可
    forEc2Instance.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443));

    this.forInstanceConnectEndpoint = forInstanceConnectEndpoint;
    this.forEc2Instance = forEc2Instance;
  }
}

export class MainStack extends cdk.Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    /**
     * VPC の作成
     */
    const vpc = new ec2.Vpc(this, 'VPC', {
      subnetConfiguration: [
        // NAT Gateway を作成するパブリックサブネット
        {
          cidrMask: 24,
          name: 'Public',
          subnetType: ec2.SubnetType.PUBLIC,
        },
        // 接続先の EC2 Instance を作成するプライベートサブネット
        // インスタンスから外部への通信を許可するために Egress ルートを追加
        {
          cidrMask: 24,
          name: 'PrivateWithEgress',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
        // EC2 Instance Connect Endpoint を作成するプライベートサブネット
        {
          cidrMask: 24,
          name: 'PrivateIsolated',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });

    const securityGroups = new SecurityGroupConstruct(
      this,
      'SecurityGroups',
      vpc
    );

    /**
     * EC2 Instance Connect Endpoint を作成
     */
    new ec2.CfnInstanceConnectEndpoint(this, 'InstanceConnectEndpoint', {
      subnetId: vpc.selectSubnets({
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      }).subnetIds[0],
      securityGroupIds: [
        securityGroups.forInstanceConnectEndpoint.securityGroupId,
      ],
    });

    /**
     * EC2 Instance を作成
     */
    new ec2.Instance(this, 'Ec2Instance', {
      vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      },
      instanceType: new ec2.InstanceType('t3.micro'),
      machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2023,
      }),
      securityGroup: securityGroups.forEc2Instance,
    });
  }
}
以前のコードはこちら
lib/cdk-sample-stack.ts
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class CdkSampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // VPC の作成
    const vpc = new ec2.Vpc(this, 'VPC', {
      subnetConfiguration: [
        // NAT Gateway を作成するパブリックサブネット
        {
          cidrMask: 24,
          name: 'Public',
          subnetType: ec2.SubnetType.PUBLIC,
        },
        // 接続先の EC2 Instance を作成するプライベートサブネット
        // インスタンスから外部への通信を許可するために Egress ルートを追加
        {
          cidrMask: 24,
          name: 'PrivateWithEgress',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
        // EC2 Instance Connect Endpoint を作成するプライベートサブネット
        {
          cidrMask: 24,
          name: 'PrivateIsolated',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });

    /**
     * EC2 Instance Connect Endpoint に関するリソースを作成
     */

    // EC2 Instance Connect Endpoint に関連付けるセキュリティグループ
    const instanceConnectEndpointSecurityGroup = new ec2.SecurityGroup(
      this,
      'InstanceConnectEndpointSecurityGroup',
      {
        vpc,
        allowAllOutbound: false,
      }
    );

    // EC2 Instance Connect Endpoint を作成
    new ec2.CfnInstanceConnectEndpoint(this, 'InstanceConnectEndpoint', {
      subnetId: vpc.selectSubnets({
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      }).subnetIds[0],
      securityGroupIds: [instanceConnectEndpointSecurityGroup.securityGroupId],
    });

    /**
     * EC2 Instance と EC2 Instance Connect Endpoint 間の通信を許可するためのルールを設定
     */

    // EC2 Instance に関連付けるセキュリティグループ
    const ec2InstanceSecurityGroup = new ec2.SecurityGroup(
      this,
      'Ec2InstanceSecurityGroup',
      {
        vpc,
        allowAllOutbound: false,
      }
    );

    // EC2 Instance Connect Endpoint から EC2 Instance への通信を許可するための Ingress ルール
    ec2InstanceSecurityGroup.addIngressRule(
      instanceConnectEndpointSecurityGroup,
      ec2.Port.tcp(22)
    );

    // EC2 Instance Connect Endpoint から EC2 Instance への通信を許可するための Egress ルール
    instanceConnectEndpointSecurityGroup.addEgressRule(
      ec2InstanceSecurityGroup,
      ec2.Port.tcp(22)
    );

    /**
     * EC2 Instance および関連リソースを作成
     */

    // EC2 Instance を作成
    new ec2.Instance(this, 'Ec2Instance', {
      vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      },
      instanceType: new ec2.InstanceType('t3.micro'),
      machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2023,
      }),
      securityGroup: ec2InstanceSecurityGroup,
    });

    // EC2 Instance から外部への通信は 443 ポートのみ許可
    ec2InstanceSecurityGroup.addEgressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(443)
    );
  }
}

AWS CDK で EC2 Instance Connect Endpoint を構築する場合は、L1 Construct class である CfnInstanceConnectEndpoint が利用可能なので利用しています。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.CfnInstanceConnectEndpoint.html

ちなみに以下の記事にある通り、EC2 Instance Connect Endpoint の提供開始直後は CfnInstanceConnectEndpoint が未提供だったため、AwsCustomResource を利用しての構築が必要だったようです。

https://dev.classmethod.jp/articles/create-ec2-instance-connect-endpoint-using-cdk-custom-resource/

デプロイ、動作確認

前述の CDK の実装をデプロイした様子です。(2025/01/03 更新)

以前のコードの実装をデプロイした様子はこちら

作成したEndpoint を使ってマネジメントコンソールから EC2 Instance Connect による接続を実施してみます。ここで選択されているEndpoint 名冒頭の eice というのは「EC2 Instance Connect Endpoint」の略です。

インスタンスに SSH で接続できました。

インターネット経由で外部ツール(ここでは psql)のダウンロードおよびインストールもできることが確認できました。

[ec2-user@ip-10-0-2-220 ~]$ sudo dnf install postgresql15
Last metadata expiration check: 18:05:21 ago on Sat Sep 28 14:25:01 2024.
Dependencies resolved.
=================================================================================================================================================================
 Package                                         Architecture                 Version                                    Repository                         Size
=================================================================================================================================================================
Installing:
 postgresql15                                    x86_64                       15.8-1.amzn2023.0.1                        amazonlinux                       1.6 M
Installing dependencies:
 postgresql15-private-libs                       x86_64                       15.8-1.amzn2023.0.1                        amazonlinux                       145 k

Transaction Summary
=================================================================================================================================================================
Install  2 Packages

Total download size: 1.8 M
Installed size: 6.9 M
Is this ok [y/N]: y
Downloading Packages:
(1/2): postgresql15-private-libs-15.8-1.amzn2023.0.1.x86_64.rpm                                                                  1.3 MB/s | 145 kB     00:00
(2/2): postgresql15-15.8-1.amzn2023.0.1.x86_64.rpm                                                                                11 MB/s | 1.6 MB     00:00
-----------------------------------------------------------------------------------------------------------------------------------------------------------------
Total                                                                                                                            7.9 MB/s | 1.8 MB     00:00
Running transaction check
Transaction check succeeded.
Running transaction test
Transaction test succeeded.
Running transaction
  Preparing        :                                                                                                                                         1/1
  Installing       : postgresql15-private-libs-15.8-1.amzn2023.0.1.x86_64                                                                                    1/2
  Installing       : postgresql15-15.8-1.amzn2023.0.1.x86_64                                                                                                 2/2
  Running scriptlet: postgresql15-15.8-1.amzn2023.0.1.x86_64                                                                                                 2/2
  Verifying        : postgresql15-15.8-1.amzn2023.0.1.x86_64                                                                                                 1/2
  Verifying        : postgresql15-private-libs-15.8-1.amzn2023.0.1.x86_64                                                                                    2/2

Installed:
  postgresql15-15.8-1.amzn2023.0.1.x86_64                                  postgresql15-private-libs-15.8-1.amzn2023.0.1.x86_64

Complete!
[ec2-user@ip-10-0-2-220 ~]$ psql --version
psql (PostgreSQL) 15.8

注意点

Endpoint のサブネットの更新などを行うと、複数作成エラーが発生する場合がある

EC2 Instance Connect Endpoint が配置されているサブネットの更新などを行うとEndpoint の再作成が行われますが、その際に CDK デプロイが次のようなエラーになる場合があります。

12:07:25 AM | UPDATE_FAILED        | AWS::EC2::InstanceConnectEndpoint     | InstanceConnectEndpoint
Resource handler returned message: "You've reached the quota for the maximum number of Instance Connect Endpoints for this subnet. Delete unused Instance Connect Endpoints, or request a quota increase. (Service: Ec
2, Status Code: 400, Request ID: 5d5d8149-4b42-4e62-a92d-ad065afe1211)" (RequestToken: f745bea4-6329-590c-a0b2-6c07319c9731, HandlerErrorCode: ServiceLimitExceeded)

これは各単位ごとのEndpoint の最大作成数は次のようになっており、今回だと VPC およびサブネットの最大作成数のクォータに抵触したためエラーとなっていました。

https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/eice-quotas.html

単位 最大作成数
AWS アカウントごと 5
VPC あたり 1
サブネットあたり 1

CfnInstanceConnectEndpoint は L1 Construct であり L2 Construct のようにリソース再作成のハンドリングを上手く行ってくれない場合があるため、注意するようにしましょう。

Endpoint を再作成したら前のEndpoint がしばらく選択可能になる?

EC2 Instance Connect Endpoint を CDK で再作成した後に、Endpoint を使用して EC2 Instance Connect を試そうとすると、再作成前と後のEndpoint がどちらも選択可能になるようになってしまいました。

これは実際にリソースが残っているわけではなくブラウザのキャッシュの影響によるもののようで、キャッシュを削除したら現在作成されているEndpoint のみが選択可能になりました。何回か再作成を繰り返していると選択可能なEndpoint が 3 つ、4 つと増えていったので少し戸惑いました。

おわりに

EC2 Instance Connect Endpoint で PrivateWithEgress に配置された EC2 Instance に接続する構成のリソース一式を AWS CDK で作成してみました。

まだ L1 Construct のみの提供ですが、設定項目は多くないため、再作成時の複数作成エラーのハンドリングを除くと、CDK での構築も比較的簡単に行えました。

以上

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.